iT邦幫忙

2022 iThome 鐵人賽

DAY 27
0
Modern Web

一步一腳印,我的前端工程師修煉系列 第 27

Day 27 | Variables: Scopes, Environments, and Closures

  • 分享至 

  • xImage
  •  

大綱

  • 宣告一個變數
  • 背景知識:靜態 vs. 動態
  • 背景知識:變數的範疇
  • 變數以函式為範疇
  • 變數宣告會被拉升
  • 藉由一個 IIFE 引進一個新的範疇
  • 全域變數
  • 全域物件
  • 環境:管理變數
  • Closures:與該誕生範疇保持連接的函式

宣告一個變數

這裡是講到一些關於宣告變數一些很基本的事。

情境 1:宣告變數跟不宣告變數

Example:

var foo; // 使用 var 來宣告一個變數叫做 foo

foo; // 一個未賦予值的變數,會得到 undefined

foo = 3; // 把 3 這個數字賦予給 foo

bar = 5; // 這裡不建議這麼做,bar 是一個未宣告的變數

情境 2:宣告變數且賦予初始值

Example:

var abc = 3; // 使用 var 來宣告一個變數叫做 abc,接著初始化 (賦予該變數初始值)

背景知識:靜態 vs. 動態

靜態地 (statically),或又稱為語彙式的 (lexically)

Example:

function f() {
	function g() { // 函式 g 內嵌於函式 f 中。
		// ...
	}
}
  • 上述例題的意思是,寫了兩個 function 分別是 f 跟 g,但是又不去執行它 (呼叫) 這樣。

動態地 (dynamically)

Example:

function f() {
	console.log('Hello')
}
function g() {
	f()
}

g() // 執行函式 g
  • 這邊的意思是使用函式宣告了 f() 跟 g()。
  • 把呼叫 f() 寫在 g() 裡。
  • 當我們呼叫 g() 時,它會呼叫 g()。又可以講為 f 被 g 所呼叫,這樣的行爲,就可稱為一種動態關係。

背景知識:變數的範疇

這裡將介紹四個概念:

一個變數的範疇

Example:

function foo() {
	var x;
}
  • 一個變數的範疇 (scope),也就是這個變數能夠被存取的位置。
  • 以上述這個例題來講,變數 x 的直接範疇就是函式 foo()。

語彙範疇 (lexical scoping)

  • 只要知道在 JavaScript 中的變數都是語彙範疇 (lexically scoped)。
  • 一個程式的靜態結構 (承上面主題靜態 vs. 動態) 決定了一個變數的範疇,要特別注意的是跟函式是從何處被呼叫沒有關係。

Example:

function foo() {
	function goo() {
		var x; // x 變數的範疇是在 goo() 函式裡面
    console.log(x)
	}
	function zoo() {
		// ....
	}
  goo() // x 變數的範疇跟在這裡呼叫 goo() 函式沒有關係
}

foo()

巢狀範疇 (nested scopes)

  • 顧名思義就是該範疇被內嵌在另一個變數的直接範疇內,那麼在這範疇內,都能使用該變數。

Example:

function foo(arg) {
	function bar() {
		console.log(`arg: ${arg}`)
	}
	bar()
}

foo('結衣結婚了 ... 哭')
  • arg 這個引數的直接範疇是 foo() 函式。
  • 在 foo() 函式裡面又內嵌了一個 bar() 函式範疇,因此也是能夠存取 arg 這個引數的值。
  • 這種多層的範疇就稱為巢狀範疇,foo() 函式被稱為外層範疇,bar() 被稱為內層範疇。

遮蔽 (shadowing)

Example:

var x = 'global'

function f() {
  var x = 'local'
  console.log(x)
}

f()

console.log(x)
  • 外圍的範疇也就是 example.js 這個檔案的範疇中,宣告了跟範疇內部同樣的變數名稱。
  • 外圍的範疇宣告了一個 x 的變數。
  • 在 f() 函式裡也宣告了 x 的變數,因跟外圍的 x 變數同名,因此外圍的 x 變數,會被阻斷在這個內層的範疇內。
  • 在 f() 函式這個範疇內,針對 x 變數重新賦予值,是不會影響外圍的 x 變數。

Example:

針對上述第四點,這裡在使用此範例再說明一次。

function f() {
  var x = 'local'
  console.log(x) // 這裡會印出 local
}

f()

console.log(x) // 這裡不會得到 local,會得到 ReferenceError: x is not defined

變數以函式為範疇

  • 多數的程式語言以區塊為範疇 (block-scoped)。
  • 變數的存活 (我會稱為存取) 是以包圍這個變數的最內層程式碼區塊中。

以 Java 為例

Example:

public static void main(string[] args) {
	{
		int foo = 4
	}
	System.out.printIn(foo) // 會出現 Error
}
  • 變數 foo 只能在直接包圍這個變數的區塊內存取,因此,一但在這個區塊外存取,就會產生編譯錯誤。

以 JavaScript 為例

  • JavaScript 的變數則是以函式為範疇 ⇒ 在 JavaScript 中,透過使用 var 來宣告的變數會是以函式為範疇 。

Example:

function main() {
	{
		var foo = 4
	}
	console.log(foo) // 會得到 4
}

main()
  • 在這裡 foo 在整個 main() 函式中都可以存取,不限於這個區塊。

變數宣告會被拉升

變數宣告的拉升

  • JavaScript 會拉升 (hosting) 所有的變數宣告,將它們移動到直接範疇的開頭處。

Example:

function f() {
	consol.log(bar);  // 1. undefined
	var bar = 'Hello';
	console.loeg(bar);  // 2. hello
}
  • 第一個 consol.loeg(bar); 會印出 undefined 而不是 bar is not defined ,是因為使用到 var 來宣告變數 bar,宣告變數 bar 會被拉升到第一行。
  • 接著再賦值給 bar 這個變數。

Example:根據上述例題,程式實際上會這樣執行

function f() {
	var bar;
	console.log(bar);  // 這時候還未給值,因此會得到 undefined
	bar = 'Hello';
	consol.loeg(bar);  // 會得到 hello
}

變數重複宣告

這裡是講到重複宣告的話,會發生的事情。

Example:情境1

var x = 1;

var x;

console.log(x) // 會得到 1

Example:情境2

var x = 1;

var x = undefined;

console.log(x) // 會得到 undefined

Example:情境3

var x = 1;

var x = 2;

console.log(x) // 會得到 2
  • 重複宣告已存在的變數,是什麼事都不會發生。
  • 關鍵在於是否有重新賦予值給該變數。

指定值給未宣告的變數會讓該變數成為全域

非 strict 模式

Example:

function f() {
	x = 123; // 不使用 var、let、const 來宣告
}
f()
console.log(x)        // 會得到 123   
console.log(window.x) // 會得到 123

strict 模式

function f() {
  'use strict'
	x = 123;
}
f() // 程式運行到這就會出現,x is not defined 的錯誤訊息,在這之下的程式就不執行了
console.log(x)
console.log(window.x)

藉由一個 IIFE 引進一個新的範疇

情境:藉由創造一個新的範疇來限制一個變數的生命週期。

在哪發生:if else 陳述句中,當條件成立時才會執行我們要執行的動作。

Example:

function f() {
	if (true) { // 條件成立時,程式執行區塊
		var foo = 123;
	}
	console.log(foo) // 這裡依然可以得到 foo 變數的值 123,可這不是我們想要的
}

f()

Example:如何解決上述 foo 變數,依然可以得到值的問題

function f() {
	if (true) {        // 條件成立時,程式執行區塊
		(function () {   // 創造一個新的函式範疇 
			var foo = 123; // foo 這個變數被鎖在新的函式範疇
		}());
	}
	console.log(foo)   // 會得到 ReferenceError: foo is not defined
}

f()
  • 這是 JavaScript 中常見的一種格式。
  • 有一人名為 Ben Alman 他稱為這叫 IIFE (即刻調用的函式運算式)
  • IIFE 全名為 immediately invoked function expression ,唸法叫「iffy」。

IIFE 基本須知

IIFE 基本格式

Example:

(function () { // IIFE 開頭
	// IIFE ... 內部
}()); // IIFE 結尾

它是即刻被調用的

  • 在該函式結尾的 } 右大括號之後的 () 小括號,代表該函式即刻調用,套一句我們比較熟的說話,就是立即呼叫該函式這樣。

它必須是一個運算式

  • 通常在使用關鍵字 function 開頭時,JavaScript 中的剖析器 (parser) 會預期這是一個函式宣告的用法。
  • 函式宣告是不會立即被呼叫又或者是執行的。
  • 因此,以左小括號 ( 為開頭,來做為該陳述句的開頭,告訴 JavaScript 中的剖析器 (parser),這個使用 function 是一個函式運算式的開頭。

尾隨的分號是必要的

Example:在兩個 IIFE 之間,第一個 IIFE 並未加上分號

(function () { // 第一個 IIFE
	// ...
}()) // 沒有加上分號
(function () { // 第二個 IIFE
	// ...
}());
  • 第一個 IIFE 是要被呼叫的函式。
  • 第一個 IIFE 未結束,第二個 IIFE 被當成第一個 IIFE 的參數來看待。

Example:

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/27fe2ea9-2b71-47da-afae-97c1a98b9458/Screen_Shot_2021-05-20_at_17.37.29.png

  • 以我目前實際的專案來看,我們是不使用分號的。
  • 但是,在這兩個 IIFE 之間,第一個 IIFE 未加上分號,會有這樣的錯誤,意思是第一個 IIFE 程式未結束。

IIFE 的變化:前綴運算子

可使用前綴運算子來強制要求運算式情境。

邏輯 Not 運算子

Example:

!function () { // IIFE 開頭
			// IIFE 內部
}();  // IIFE 結尾

void 運算子

Example:

void function () { // IIFE 開頭
			// IIFE 內部
}();  // IIFE 結尾
  • 使用這兩個運算子的好處是即使忘了終止用的分號,程式也不會產生錯誤。

IIFE 的變化:已經處於運算式情境

  • 如果該函式已經是函式運算式的用法,哪也就不需要為一個 IIFE 強制要求運算式情境 (因為,該函式就已經是類似函式運算式的用法)。
  • 更進一步說,不需要使用括號或前綴運算子。

Example:

var File = function () { // IIFE 開頭
	var UNTITLED = 'untitled'
	function File(name) {
		this.name = name || UNTITLED
	}
	
	return File
	/*
		function File(name) {
			this.name = name || UNTITLED
		} 
	*/
}(); // IIFE 結尾

File()
  • 這裡 return 的 File,是宣告在這個 IIFE 裡的 File 函式。

IIFE 的變化:帶有參數的 IIFE

可以使用參數來做為 IIFE 的內部定義變數。

Example:

var x = 23;

(function (twice) {
	console.log(twice) // 會得到 46
}(x * 2))

// 更像是如下
var x = 23;

(function () {
	var twice = x * 2;
	console.log(twice); // 會得到 46
}())

IIFE 的應用

  • 可以為一個函式附上私有的資料 (private data)。
  • 不需樣宣告一個全域變數,並且可將該函式與狀態 (state) 包裹起來。
  • 這個全域變數也不會污染到這個全域命名空間。

Example:

var setValue = function () {
  var prevValue;
  return function (value) {
    if (value !== prevValue) { // 定義 setValue
      console.log('Changed: '+ value); // "Changed: Hello"
      prevValue = value
    }
  };
}();

setValue('Hello')

Example:等於以上程式碼

var setValue = function () {
  var prevValue;
  return function (value) {
    if (value !== prevValue) { // 定義 setValue
      console.log('Changed: '+ value); // "Changed: World"
      prevValue = value
    }
  };
};

setValue()('World')

IIFE 的其他應用

後面幾個章節都會講到,這裡就不在一一說明。

全域變數

  • 涵蓋了一個程式所有部分的範疇 (scope) 就被稱為全域範疇 (global scope) 或程式範疇 (program scope)。
  • 像是:網頁中的 <script> 標記、某某 .js 檔案。

Example:

// 這裡就是全域範疇
var globalVariable = 'globalValue';

function f() {
  var localVariable = true;
  function g() {
    // 周圍範疇的所有變數都能夠存取
    var anotherLocalVariable = 123;
    
    localVariable = false;
    
    globalVariable = 'anotherLocalValue';
    
    console.log(globalVariable);
    
  }
  g()
}

f()

console.log(globalVariable);
// 這裡我們再次回到了全域範疇

最佳實務做法:避免建立全域變數

全域變數會有兩個缺點。

  • 仰賴全域變數運行的軟體容易受到副作用 (side effect) 的影響,會不穩固、行為較難預測,也沒辦法重複使用。
  • 如果這個專案中都使用相同的全域變數:像是程式碼、內建元件等等。代表名稱的衝突會導致程式無法運行。

Example:

<script>
// 全域範疇

var tmp = generateData();

processData(tmp);
persistData(tmp);
</script>
  • 變數 tmp 變成全域的。
  • 有可能這個程式多了之後,在某行 tmp 有可能會被污染到,導致這個程式會無法運行。

Example:藉由一個 IIFE 來引進一個新的範疇

<script>
// 全域範疇

(function () { // IIFE 開頭
	// 區域範疇
	var tmp = generateData();

	processData(tmp);
	persistData(tmp);
}()); // IIFE 結尾

</script>

模組化的系統可以產生較少的全域值

待後面章節會講到。

要特別講到的是在 ES6 多了 import & export 這兩個用法。

全域物件

  • ECMAScript 規格是使用內部資料結構 environment (環境) 來儲存變數。
  • 全域變數的環境可透過一個物件,也就是所謂的全域物件 (global object)。
  • 這個全域物件可被用來建立、讀取與修改全域變數內的值。
  • 在全域範疇 (global scope) 中,this 指向的就是它。

Example:

var foo = 123;

this.foo; // 讀取全域變數,會得到 123

this.bar = 456; // 建立全域變數

bar // 會得到 456

跨平台的考量

瀏覽器與 Node.js 都有用來參考這個全域物件的全域變數。

瀏覽器

  • 瀏覽器是 window,它是 Document Object Model (DOM) 的一部分,而不是 ECMAScript 5 的一部分。
  • 每個頁框或是視窗都會有一個這個 window 全域物件。

Node.js

  • node.js 是 global,它是 Node.js 專屬的變數。
  • 也因此每個模組都會有它自己的範疇,因此,this 與 global 會是不同的。

這兩者的共通點

  • this 都會指向全域物件,也只限於我們是在全域範疇中的時候。

想要在跨平台存取這個全域物件

Example:

(function (glob) {
	// glob 指向全域物件
}(typeof window !== 'undefined' ? window : global));

window 的用例

這節將講到在什麼情境下,會使用到 window 來存取全域變數,這裡書中也提到,能盡量避免的話,盡量不要這樣做。

用例:標示全域變數

window 這個前綴 (prefix) 是一種視覺線索,告訴我們程式碼參考到一個全域變數,而非區域變數。

Example:

var foo = 123;
(function () {
	console.log(window.foo); // 會得到 123
}());

Example:上述程式碼很容易會被範疇所影響到


(function () {
	var foo = 123;
	console.log(window.foo); // 因為 window.foo 沒有這個屬性,因此會得到 undefined
}());

呈上述程式碼,要怎麼避免這個狀況。

  • 把這個變數當作一個變數來參考,而非 window 的特性。
  • 該變數的名稱加上前綴詞來表示。

Example:

var g_foo = 123; // 加上 g 這個前綴詞
(function () {
	console.log(g_foo); // 不使用 window 來存取 g_foo,而是用一個變數的角度來看待
}());

用例:內建功能

Example:

window.isNaN(...) // 不要這樣做,會很混亂

isNaN(...) // 這樣是最好的

用例:風格檢查器

  • 可以使用 JSLint、JSHint、ESLint 這類的風格檢查器,來避免掉這種錯誤產生。
  • 如果我們使用 window 來存取某個特性的值,哪就代表我們沒有宣告於目前檔案中的全域變數。

用例:檢查一個全域變數是否存在

這裡要講的是,如果要檢查某個全域變數是否存在,該怎麼做是最恰當的。

Example:

if (window.someVariable) {...} // 1 使用 window 來檢查

if (someVariable) {...} //  2 如果 someVariable 未宣告,這個判斷會有錯誤產生,不建議

if (window.someVariable !== undefined) {...} // 比第 1 種判斷方式更明確

if ('someVariable' in window) {...} // 比第 1 種判斷方式更明確

if (typeof someVariable !== 'undefined') {...} // 判斷是否存在 (並有值)

用例:在全域範疇中建立東西

Example:

window.someVariable = 123 // 直接這樣用,就可在全域變數新增該特性

var someVariable = 123    // 建議作法,還是使用變數宣告的方式

環境:管理變數

變數會以兩種方式來傳遞。也可以說,它們有兩個維度 (dimensions):

動態維度:調用函式

  • 當一個函式被呼叫時,該函式會需要新的儲存空間 (這裡我會講我們常說的記憶體空間) 來存放該函式的參數與變數。
  • 接著該函式的呼叫結束後,這些儲存空間就會被回收 (這裡我會講這個函式的生命週期已結束)。

Example:

function fac(n) {
  if (n <= 1) {
    return 1;
  }
  return n * fac(n - 1);
}

console.log(fac(3));
  • 當執行 fac() 函式時會產生儲存空間來儲存 n 這個參數。
    • 第一次執行 fac(3) 函式時,會產生一個儲存空間,來儲存 n 為 3 的值。
    • 由於是遞迴函式,程式第二次執行時,又會產生一個儲存空間,來儲存 n 為 2 的值。
    • 接著程式第三次執行時,由於符合 if (n <= 1) 判斷式,所以回傳 n 為 1 的值。
    • 最後該函式執行完畢,會得到 n = 3 * 2 * 1,答案為 6 的結果。

語彙 (靜態) 維度:與你的外圍範疇保持連接

一個函式在一直被呼叫的情況下,該函式會需要存取它自己 (全新) 的區域變數及外圍範疇的變數。

Example:

function outer(n, action) {
  function inner(x) {
    if (x >= 1) {
      action();
      inner(x - 1);
    }
  }
  inner(n)
}

function foo() {
  console.log('Hello')
}

console.log(outer(3, foo));
/*
Hello
Hello
Hello

*/

處理這兩個維度的方式如下:

動態維度:執行情境的堆疊 (stack of execution contexts)

當一個函式被呼叫時,就會建立一個新的環境,由於 JavaScript 是一個單執行緒的程式語言,意思就是一次只能做一件事情,如果安排了很多事情要給他做,他就會讓這些事情去排隊,再一件一件做。

像書中提到的遞迴,又或是一般的函式呼叫也好,這些在當下都會被放入一個堆疊 (stack) 中來管理。這是個「後進先出」的概念,以下面這段程式為例:

Example:

function a() {
  b();
}
function b() {
  console.log('hi');
}
a();

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/bf0662e7-4a60-4126-b11e-11812420655f/Screen_Shot_2021-05-21_at_16.02.36.png

語彙維度:環境所成的串鏈 (chain of environments)

Example:

function myFunction(myParam) { // 函式宣告
  var myVar = 123;
  console.log(myFloat)
  return myFloat;
}

var myFloat = 1.3;

myFunction('abc')
  • myFunction 與 myFloat 這兩個都會儲存到全域環境裡面。
  • 在 myFunction 函式裡面,可以讀取外層宣告的變數,但在外層的 myFunction 函式存取不到內層宣告的 mvVar 變數。所以,若是在自己層級找不到就會一層一層往外找,直到 global 為止。

Closures:與該誕生範疇保持連接的函式

所謂的閉包 (Closure) 就是「在函數內引用區域變數的函式」,哪我知道這樣的解釋還是非常抽象跟不明遼,所以直接來看例題吧。

Example:

function closure(init) {
	var counter = init;

	return function () {
		return ++counter;
	}
}

var myClosure = closure(1)
console.log(myClosure()); // 2
console.log(myClosure()); // 3
console.log(myClosure()); // 4
  • 第一先來看這個 closure 函式,我使用 init 當做參數來做為初始值傳入,然後執行遞增運算並回傳該結果。
  • 第二要注意的是該針對這個 closure 函式,它的回傳值是執行遞增運算的匿名函式,這裡可以講一個專有名詞叫「高階函數」。

在這個函式中我們使用了巢狀函式來做為回傳值,關於這點就是屬於閉包的機制了。

在一般的函式概念來講可使用的區域變數 (這裡是變數 counter)在函式處理結束後,就會被回收這樣。


上一篇
Day 26 | 物件與繼承
下一篇
Day 28 | Function
系列文
一步一腳印,我的前端工程師修煉30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言